Lab 06 - Klasy - rozszerzenie

Lab 06 - Klasy i wzorce - usystematyzowanie wiadomości

Informacje wstępne

Use case klasy Histogram

Jako przykład wprowadzający stworzymy klasę histogram, której celem jest reprezentacja częstości wystąpień konkretnych elementów zbioru. Histogram reprezentuję częstość wystąpień elementów w pewnym zbiorze

histogram

Klasa histogramu powinna umożliwiać gromadzenie danych i ich integrację, wizualizację oraz konwersję. Dla przykładowego zbioru danych z rysunku, wprowadzenie pełnego zbioru przedstawionego na rysunku powinno skutkować stworzeniem następującej reprezentacji wewnętrznej histogramu:

przedział wartość
1 4
2 2
7 1
8 1
9 1

W dalszym przykładzie zastosowania klasy zostanie stworzona klasa Histogram umożliwiająca reprezentację ocen. Dane wejściowe zawierają wyniki pewnego egzaminu wyrażone w punktach w skali 0-10pkt. W ogólności, histogram może reprezentować częstość występowania danych w przedziałach obejmujących kilka wartości elementów (wtedy liczba przedziałów będzie mniejsza niż liczba unikalnych wartości zbioru danych). W przykładzie jednak zakładamy, że każda unikalna wartość jest osobnym przedziałem histogramu.

Zakładamy przy tym następujący use case:

Definiowanie obiektów (konstruktory)

Histogram hist; //Konstruktor domyślny
Histogram hist_2(std::vector<int>({10,15,6,9,10,12})); // definiuje obiekt wywołując konstruktor inicjujący go listą wyników w punktach

Metody wprowadzania i modyfikacji danych do histogramu

    hist.emplace(20); //dodaje ocenę 20pkt

    hist.emplace(std::vector<int>({10, 15, 6, 9, 10, 12}));

    hist << 10 << 12 << 20 << 21; // kolejne elementy zawierają punktację poszczególnych studentów

    cin >> hist; // pobiera dane od użytkownika (pobierając najpierw liczbę wyników, które chce wprowadzić)

    hist.from_csv(R"(../wyniki.csv)", ',', 4); //wczytuje plik csv, argumentami są nazwa pliku, separator kolumn oraz index kolumny w której znajdują się dane dla histogramu

    hist_2.clear(); //usuwa wszystkie dane z histogramu // jeśli dane nie zostaną usunięte kolejne wywołania operatora zapisu do strumienia lub

Operacje na histogramie

    using Bin = int; // alias typu wartości dla przedziału histogramu
    using Frequency = int; // alias typu wartości dla częstości wystąpień
    std::cout << hist; // zapis histogramu do strumienia tekstowego

    std::ofstream file("histogram.txt");
    file << hist; // zapis histogramu do pliku (identycznie jak dla wyświetlenia go na konsoli)

    int freq = hist[10.5]; // zwraca częstość dla przedziału do którego należy wartość 10.5

    std::pair<Bin, Bin> range = hist.range(); // zwraca początkowy i końcowy przedział.

    std::pair<Bin, Frequency> max = hist.max(); // zwraca najczęściej występujący przedział oraz jego częstość

    std::vector<Bin> bins = hist.unique_bins(); // zwraca listę unikalnych, niepustych przedziałów
                                                
    std::vector<std::pair<Bin, Frequency>> items = hist.unique_items(); // zwraca listę niepustych przedziałów oraz częstość wystąpień

    //konwersja na inne typy
    using BinsVector = std::vector<std::pair<Bin, Frequency>>;
    BinsVector items_vect = static_cast<BinsVector>(hist); // operator rzutowania działa tak samo jak  Histogram::unique_items
    print(items_vect); // zewnętrzna funkcja wyświetlająca wektor elementów histogramu

🛠🔥 Zadanie 🛠🔥

  1. Przeanalizuj powyższy przykład użycia oraz umieszczone komentarze wyjaśniające. W oparciu o swoją dotychczasową wiedzę (nie analizując dalszego ciągu wprowadzenia) wyodrębnij konstruktory, metody oraz operatory niezbędne do jego uruchomienia. Zastanów się jakie atrybuty (pola) musi mieć klasa Histogram, tak by możliwa była reprezentacja i uaktualnianie histogramu. Podpowiedź: Na zajęciach Lab04 realizowałeś program wyświetlający częstość wystąpień słów z pliku tekstowego, zastanów się jak wykorzystaną tam reprezentację zastosować w klasie Histogram. Jeśli masz problem ze stworzeniem odpowiedniej reprezentacji metod i konstruktora, zapoznaj się z:
  2. Deklarację klasy umieść w pliku nagłówkowym histogram.h i dołącz go do swojego pliku źródłowego zawierającego funkcję główną oraz zamieszczony powyżej przykład użycia. Spróbuj skompilować program, zobacz dla których linii przykładu użycia kompilator zwraca błąd (np. Identifier ... not found, no member named ..., no variable conversion from...). Jeśli proces budowania wyświetla tylko błędy linkera (undefined reference to ...), oznacza to ze udało Ci się stworzyć deklarację klasy zgodną z przykładem użycia.

Pamiętaj, żeby plik nagłówkowy zawierał dyrektywy preprocesora pełniące rolę strażnika załączenia:

        #ifndef HISTOGRAM_H
        #define HISTOGRAM_H

        class Histogram
        {

        //deklaracje pól i metod

        };
        #endif

ewentualnie:

        #pragma once

        class Histogram
        {

        //deklaracje pól i metod

        };
  1. Popraw deklarację klasy Histogram stosując następujące zalecenia:
    • wszystkie pola klasy deklaruj jako prywatne,
    • wszystkie obiekty przekazywane jako argument metody/funkcji, powinny być przekazywane przez referencję Type &param (jeśli funkcja/metoda je modyfikuje) lub referencję do stałej const Type& param (jeśli są wykorzystywane do odczytu) - zabieg ten pozwoli na skrócenie czasu wykonania metody, ponieważ argument nie będzie kopiowany,
    • wszystkie metody, które nie modyfikują zawartości obiektu (należące do grupy przykładu użycia Operacje na histogramie) zadeklaruj jako metody stałe (umieszczając słowo kluczowe const na końcu deklaracji metody). Stosowanie metod stałych umożliwia ich wywołanie dla obiektu, który jest stałą (np. został przekazany jako referencja do stałej). Ponadto, dzięki temu w interfejsie klasy możemy jawnie wskazać, które metody nie modyfikują wewnętrznego stanu obiektu. np. dla metody range:
// deklaracja wewnątrz deklaracji klasy Histogram (plik histogram.h):
        std::pair<Bin, Bin> range() const;

//-------------------------
// definicja (plik histogram.cpp)
        std::pair<Bin, Bin> Histogram::range() const{
            // ciało funkcji
            return {min,max};
        }
  1. Po stworzeniu prawidłowej deklaracji spróbuj stworzyć definicje poszczególnych metod klasy Histogram umieszczając je w odrębnym pliku histogram.cpp. W metodach tj. Histogram::max, Histogram::unique_items spróbuj wykorzystać algorytmy STL std::max_element, std::copy a jeśli trzeba, wyrażenia lambda.

  2. Spróbuj zaimplmenetować uniwersalną metodę wczytującą Histogram::from_csv z argumentami umożliwiającymi podanie nazwy pliku, separatora elementów oraz kolumny w której znajdują się wartości wejściowe dla histogramu. Wczytaj załączony do zadania plik, który zawiera wyniki egzaminu w formie tabelarycznej, oddzielonej przecinkami, a wynik punktowy danej osoby jest w kolumnie 5. Uruchom przykład przekazując pobrany plik z danymi, możesz go również przetestować na danych z innym separatorem i układem kolumn.

Deklaracja klasy Histogram

Poniższej zaprezentowano pełną deklarację klasy histogram, jednak zachęcamy Ciebie żebyś z niej skorzystał tylko w celu weryfikacji lub porównania napisanego przez Ciebie kodu.

class Histogram
{
    std::map<int, int> bins_;

    using BinsVector = std::vector<std::pair<int, int>>;

public:
    Histogram(const std::vector<int> &data = std::vector<int>());
    void emplace(int v);
    void emplace(const std::vector<int> &data);

    void clear();
    bool from_csv(const std::string &filename, char delim = ',', int column_idx = 4);
    std::pair<int, int> max() const;
    std::pair<int, int> range() const;
    std::vector<int> unique_bins() const;
    BinsVector unique_items() const;

    Histogram& operator<<(int v);
    static Histogram generate(int min, int max, int count);// tworzy histogram zawierający wartości losowe, gdzie kolejne argumenty oznaczają wartość minimalną, maksymalną, liczbę elementów
    int operator[](int v) const; // zwraca częstość dla binu w indeksie
    operator BinsVector(); // operator konwersji na typ `BinsVector` (`std::vector<std::pair<int, int>>`)

    friend std::istream &operator>>(std::istream &str, Histogram &hist);
    friend std::ostream &operator<<(std::ostream &str, const Histogram &hist);
};

Metody statyczne

Załóżmy, że przykład użycia wzbogacony zostanie o możliwość inicjacji wartościami losowymi.

Histogram hist_3 = Histogram::generate(-10, 10, 100); // tworzy histogram zawierający wartości losowe, gdzie kolejne argumenty oznaczają wartość minimalną, maksymalną, liczbę elementów
Histogram hist_3 = Histogram::generate(100, [](){ return -10 + rand() % 21; }); // tworzy histogram zawierający wartości losowe,  liczbę elementów do losowania oraz wskaźnik do funkcji losującej pojedynczy element

Żeby napisać deklaracje i definicje metod Histogram::generate z przykładu użycia trzeba wyjaśnić 2 zagadnienia

Metody statyczne

Podane wyżej metody Histogram::generate są wywoływane bez konieczności użycia obiektu (instancji klasy). Metody takie nazywa się metodami statycznymi.

deklaracja metody Histogram::generate pasującej do pierwszego przykładu użycia może mieć postać:

 static Histogram generate(int min, int max, int counter);

Natomiast definicja może mieć postać:

Histogram Histogram::generate(int count, int (*func_ptr)())
{
    Histogram h;
    for (int i = 0; i < counter; i++)
        h.emplace(func_ptr());
    return h;
}

Wskaźniki do funkcji

W drugim przykładzie użycia Histogram::generate, jako argument przekazywana jest funkcja generująca pojedynczą próbkę. Dotychczas przekazywałeś wielokrotnie funkcję jako argument wywołania (np. w algorytmach STL), teraz dowiesz się w jaki sposób zdefiniować funkcję, której argumentem jest wyrażenie lambda lub inna funkcja.

W ogólności, dla funkcji zadeklarowanej jako:

 typ_zwracany nazwa_funkcji(typ_arg1, typ_arg2);

Funkcja ta, może być reprezentowana i wywoływana za pomocą swojej nazwy lub wskaźnika definiowanego następująco:

 typ_zwracany (*nazwa_wskaznika)(typ_arg1, typ_arg2)); // deklaracja zmiennej typu wskaźnik do funkcji
 nazwa_wskaznika = nazwa_funkcji; // przypisanie funkcji 'nazwa_funkcji' do wskaźnika

 nazwa_wskaznka(arg1, arg2); //wywołanie funkcji 'nazwa_funkcji' przez wskaźnik do funkcji - wywołanie jest równoważne zapisowi nazwa_funkcji(arg1, arg2)

podczas deklarowania typów funkcyjnych, użyteczne może być wykorzystanie aliasów using:

 using typ_funkcji = typ_zwracany (*)(typ_arg1, typ_arg2); // zdefiniowanie typu funkcji
 typ_funkcji nazwa_wskaznika = nullptr; // deklaracji wskaźnika na funkcję (chwilowo nie wskazuje na nic)
                                        // - deklaracja wygląda jak deklaracja zwykłej zmiennej
 nazwa_wskaznika = nazwa_funkcji; // przypisanie  funkcji `nazwa_funkcji` do wskaźnika

 typ_funkcji nazwa_wskaznika2 = nazwa_funkcji; // analogicznie do poporzedniego przykładu

Przypisanie funkcji do wskaźnika jest możliwe wtedy i tylko wtedy gdy ściśle zgodny jest typ funkcji oraz typy i liczba argumentów

Załóżmy następujące funkcje:

float sum (float a, float b){
    return a + b;
}

float mul (float a, float b){
    return a * b;
}

Wtedy możliwe jest następujące wykorzystanie wskaźnika do funkcji:

float (*operacja)(float a, float b); //deklaracja zmiennej typu wskaźnik do funkcji
// lub równoważnie
using operacja_typ = float (*)(float a, float b); // definicja typu
operacja_typ operacja; // deklaracja zmiennej typu wskaźnik do funkcji

operacja = sum; // przypisuje funkcję sum
std::cout << operacja(3, 3); // wyswietli 6 - dodawanie

operacja = mul; // przypisuje funkcję mul
std::cout << operacja(3, 3); // wyswietli 9 - mnożenie

operacja = [](float a, float b){ return a - b;}; // przypisuje wyrażenie lambda
std::cout << operacja(3, 3); // wyswietli 0 - odejmowanie za pomocą wyrażenia lambda

🛠🔥 Zadanie 🛠🔥

  1. Wskaźnik do funkcji może również być argumentem wywołania innej funkcji, wtedy jeden z argumentów funkcji jest typu wskaźnika do funkcji określającego typ, oraz argumenty funkcji. Na podstawie przedstawionych powyżej informacji stwórz deklarację i definicję metody statycznej Histogram::generate umożliwiającej wywołanie w postaci:
    Histogram hist_3 = Histogram::generate(100, [](){ return -10 + rand() % 21; }); // tworzy histogram zawierający wartości losowe,  liczbę elementów do losowania oraz wskaźnik do funkcji losującej pojedynczy element

Szablony

Funkcje szablonowe

Szablony (ang. templates) są kolejnym mechanizmem wprowadzonym w języku C++ pozwalającym zmniejszyć częstość pojawianie się duplikatów w kodzie.

Szablonem w C++ może zostać dowolna funkcja, metoda, struktura czy klasa poprzez dodanie słowa kluczowego template oraz listy parametrów szablonowych <typename T> przed deklaracją/definicją odpowiedniego elementu.

Załóżmy, że mamy dany szablon funkcji

template <typename T>
std::vector<std::vector<T>> createMatrix(int m, int n) {
\\ ...
}

Zadaniem powyższej funkcji ma być utworzenie i zwrócenie macierzy o wymiarach m na n. Gdybyśmy mieli do czynienia z konkretnym typem np. int to macierz mogłaby zostać utworzona w następujący sposób:

    std::vector<std::vector<int>> A(m);
    for (unsigned int i = 0; i < m; i++) {
       A[i] = std::vector<int>(n);
    }

Dla typu szablonowego zapis jest analogiczny:

    std::vector<std::vector<T>> A(m);
    for (unsigned int i = 0; i < m; i++) {
       A[i] = std::vector<T>(n);
    }

W momencie procesu specjalizacji szablonu T zostanie zastąpione odpowiednim typem wskazanym przez użytkownika/programistę.

Kompletna funkcja będzie miała następującą postać:

template <typename T> 
std::vector<std::vector<T>> createMatrix(int m, int n) {
    std::vector<std::vector<T>> A(m);
    for (unsigned int i = 0; i < m; i++) {
        A[i] = std::vector<T>(n);
    }
    return A;
}

Ponieważ wśród jawnych argumentów funkcji nie pojawia się żaden z typem T w związku z tym trzeba wprost określić ten typ w czasie wywołania, tzn.:

std::vector<std::vector<int>> A1 = createMatrix<int>(5,5);
std::vector<std::vector<float>> A2 = createMatrix<float>(5,5);
std::vector<std::vector<complex>> A3 = createMatrix<complex>(5,5);

W momencie kompilacji na podstawie zostaną wygenerowane następujące funkcje:

std::vector<std::vector<int>> createMatrix(int m, int n) {
    std::vector<std::vector<int>> A(m);
    for (unsigned int i = 0; i < m; i++) {
        A[i] = std::vector<int>(n);
    }
    return A;
}
std::vector<std::vector<float>> createMatrix(int m, int n) {
    std::vector<std::vector<float>> A(m);
    for (unsigned int i = 0; i < m; i++) {
       A[i] = std::vector<float>(n);
    }
    return A;
}
std::vector<std::vector<complex>> createMatrix(int m, int n) {
    std::vector<std::vector<complex>> A(m);
    for (unsigned int i = 0; i < m; i++) {
        A[i] = std::vector<complex>(n);
    }
    return A;
}

Oczywiście trzeba tutaj zaznaczyć, że gdybyśmy ręcznie powyższe funkcje zaimplementowali w ten sposób kompilacja by się nie powiodła – mamy trzy funkcje o tej samej nazwie i liście argumentów.

Kolejny przypadek wykorzystania szablonów obejmuje występowanie typu szablonowego na liście argumentów funkcji, np.:

template <typename T>
std::vector<std::vector<T>> copy(const std::vector<std::vector<T>>& matrix) {
    unsigned int m = matrix.size();
    unsigned int n = matrix[0].size();

    std::vector<std::vector<T>> matrixCopy = createMatrix(m, n);
    for (unsigned int i = 0; i < m; i++) {
        for (unsigned int j = 0; j < n; j++) {
         matrixCopy[i][j] = matrix[i][j];
    }
    return matrixCopy;
}

Przykład programu wykorzystujący tę funkcję może wyglądać następująco:

auto A = createMatrix<int>(5, 6);
auto B = copy(A);

Jak można zauważyć w wywołaniu funkcji copy nie potrzeba już jawnie określać typu szablonu.

Szablony struktur i klas

Zdefiniowanie szablonów struktury lub klasy polega na umieszczeniu przed jej deklaracją słowa kluczowego templateoraz listy parametrów szablonowych <typename T>. Załóżmy, że chcemy stworzyć szablon klasy Histogram tak by możliwe było zliczanie nie tylko liczb całkowitych (jak w poprzenim przykłądzie, lecz również elementów dowolnych typów. Dla uprzednio zdefiniowanej klasy Histogram wykorzystanie szablonów wygląda następująco: (funkcje i klasy szablonowe wraz z implementacją umieszcza się w 99% przypadków w plikach nagłówkowych)

// fragment klasy Histogram z poprzednich przykładów
template <typename T>
class Histogram
{
    std::map<T, int> bins_;

    using BinsVector = std::vector<std::pair<T, int>>;

public:
    Histogram(const std::vector<T> &data = std::vector<T>()) {
        // to implement
    }
    void emplace(const T& v);{
        // to implement
    }
    void emplace(const std::vector<T> &data) {
        // to implement
    }

    void clear() {
        // to implement
    }
    bool from_csv(const std::string &filename, char delim = ',', int column_idx = 4) {
        // to implement
    }
    std::pair<T, int> max() const {
        // to implement
    }
    std::pair<T, T> range() const {
        // to implement
    }
    std::vector<T> unique_bins() const {
        // to implement
    }
};

Analizują przykład można zauważyć, że typ szablonowy może zostać zastosowany zarówno w typach pól struktury/klasy (bins_) jak i w jej metodach czy konstruktorach. Należy pamiętać, że wszystkie funkcje i struktury szablonowe muszą być zaimplementowane w plikach nagłówkowych!

Wykorzystanie powyższego szablonu umieszcza się zazwyczaj w plikach *.cpp, w których dołączany jest nagłówek z zdefiniowanym szablonem. Np.:

#include "histogram.h"

int main()
{
    Histogram<int> histogram_int;
    Histogram<float> histogram_float;
    Histogram<std::string> histogram_of_names;

    histogram_int.emplace(5);
    histogram_float.emplace(12.3f);

    histogram_of_names.emplace("John");
    histogram_of_names.emplace("John");
    histogram_of_names.emplace("John");
    histogram_of_names.emplace("Maria");
    histogram_of_names.emplace("Maria");
    
    std::pair<std::string, int> mostFrequent = histogram_of_names.max();
    // lub
    auto mostFrequent2 = histogram_of_names.max();
}

Uwaga: Ponieważ kompilator w momencie generowania implementacji funkcji, struktury musi wiedzieć wszystko danym szablonie (musi znać również ciało funkcji szablonowej), przez co wszystkie szablony powinny być umieszczone w pliku nagłówkowym modułu.


Zadanie domowe 🏠🔥

Zadanie 1

Stwórz pełną deklarację szablonu klasy Histogram i uruchom ją dla kilku przykładów, gdzie wejściem są słowa (np. plik) i liczby (np. plik). Spróbuj tak zparametryzować metodę Histogram::from_csv, żeby umożliwiała wczytanie obu plików. Zaimplementuj również szablonową funkcję print

Zadanie 2

Z wykorzystaniem szablonów zaimplementuj obsługę liczb zespolonych, gdzie precyzja reprezentacji części rzeczywistej i urojonej jest określona przez użytkownika/programistę biblioteki. Wykorzystując szablonową wersję implementacji liczb zespolonych napisz program, który je wykorzystuje. Użyj typów bazowych: int, float, double.

Zadanie 3

Z wykorzystaniem szablonów zaimplementuj operacje dodawania, odejmowania i mnożenia skalarnego dwóch wektorów o długości n (tablice jednowymiarowe) oraz wyświetlania na standardowym wyjściu. Następnie przetestuj działanie dla par wektorów o następujących typach: int, float, complex. Wektory możesz wypełnić wartościami losowymi. Wykorzystaj zaimplementowana wcześniej bibliotekę liczb zespolonych.

Zadanie 4

Napisz klasę Matrix reprezentującą macierz i umożliwiającą wykorzystanie dla następującego przykładu użycia:

// tworzy macierze 3x3
Matrix<double> M(3, 3);
Matrix<double> C({{1, 0, 0},
                  {0, 1, 0},
                  {0, 0, 1}});
Matrix<double> D;

std::cin >> D; // pobiera dane od użytkownika (zarówno jej wymiar jak i wartości poszczególnych elementów)

Matrix<int>  X = Matrix::eye(3,3); // metoda statyczna, zwraca macierz jednostkową
std::cout << X << std::endl;

// inicjalizacja zmienną losową
static std::default_random_engine e{};
std::uniform_int_distribution<int> distriubution{0, 100};
Matrix<int>  Y = Matrix::fill(3, 3, [&distribution](){ return distribution(e); }); // metoda statyczna, zwraca macierz o wymiarze 3x3, wypełnioną wartościami generowanymi przez funkcję będącą trzecim argumentem

Matrix<double>  B = 5 * M * D * 5 + 1; // operacje arytmetyczne na macierzach - zdefiniuj wszystkie niezbędne operatory
std::cout << B << std::endl;

Uwagi: - operacja Macierz + skalar powinna dodawać do każdego z elementów macierzy wartość skalara, - zwróć uwagę, że skalar * Macierz, Macierz * skalar to dwa różne operatory. - operacje dodawania/mnożenia macierzy powinny weryfikować prawidłowość rozmiaru macierzy, będących argumentami, w przypadku niemożliwości przeprowadzenia operacji (wskutek niedopasowania wymiaru) metoda powinna zwracać standardowy wyjątek std::out_of_range (patrz przykład z wykładu).


Autorzy: Piotr Kaczmarek, Przemysław Walkowiak